// © LunqFX
//@version=6

indicator("Nova Reversal Bands by LunqFX", shorttitle="NRB", overlay=true, max_labels_count=500)

// ═══════════════════════════════════════════════════════════════════════════
// INPUTS
// ═══════════════════════════════════════════════════════════════════════════
grpCore = "⚙️ Core Settings"
src            = input.source(close,  "Source",                      group=grpCore)
fairLen        = input.int(50,        "Fair Value Length", minval=1,  group=grpCore)
zLen           = input.int(100,       "Z-Width Lookback",  minval=10, group=grpCore)
smoothLen      = input.int(18,        "Band Smoothness",   minval=1,  group=grpCore)
upperMult      = input.float(2.4,     "Upper Band Multiplier", step=0.1,  group=grpCore)
lowerMult      = input.float(2.4,     "Lower Band Multiplier", step=0.1,  group=grpCore)
outerUpperMult = input.float(1.45,    "Outer Upper Multiplier", step=0.05, group=grpCore)
outerLowerMult = input.float(1.45,    "Outer Lower Multiplier", step=0.05, group=grpCore)

grpSignals = "🎯 Signals"
enableSignals = input.bool(true,  "Show Signals",        group=grpSignals)
cooldownBars  = input.int(5,      "Signal Cooldown",     minval=0, group=grpSignals)
showStrength  = input.bool(true,  "Show Strength Stars", group=grpSignals)

grpVisual = "🎨 Visuals"
candleMode     = input.string("Reversal Heat", "Candle Coloring Mode",
     options=["Reversal Heat", "Latest Signal"], group=grpVisual)
showOuterBands = input.bool(false, "Show Outer Bands",  group=grpVisual)
showGravity    = input.bool(true,  "Show FV Pull Line", group=grpVisual)

grpAdvanced = "🧪 Advanced"
compressionThresh = input.float(0.75, "Compression Threshold",  minval=0.3, maxval=1.0, step=0.05, group=grpAdvanced)
exhaustionCount   = input.int(3,      "Exhaustion Rejections",  minval=2,   maxval=8,   group=grpAdvanced)
usePatternFilter  = input.bool(true,  "Reversal Candle Filter (pin bar / engulfing)", group=grpAdvanced)
showPanel         = input.bool(true,  "Show Info Panel",        group=grpAdvanced)

grpLine = "📐 Line Coloring"
lineSlopeLen         = input.int(5,     "Slope Smoothing Length",   minval=1,    group=grpLine)
lineSlopeSensitivity = input.float(1.25,"Slope Color Sensitivity",  step=0.05,   group=grpLine)

// ═══════════════════════════════════════════════════════════════════════════
// COLORS
// ═══════════════════════════════════════════════════════════════════════════
bullMain     = color.new(#38BDF8, 0)   // Sky blue
bearMain     = color.new(#F97316, 0)   // Flame orange
goldCol      = color.rgb(255, 215, 0)  // Gold — squeeze
labelTextCol = color.white
borderCol    = color.new(color.white, 50)
emptyCol     = color.new(#000104, 100)

// ═══════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
f_colorGradient(_ratio, _colA, _colB) =>
    rA = color.r(_colA)
    gA = color.g(_colA)
    bA = color.b(_colA)
    rB = color.r(_colB)
    gB = color.g(_colB)
    bB = color.b(_colB)
    color.rgb(rA + int((rB - rA) * _ratio), gA + int((gB - gA) * _ratio), bA + int((bB - bA) * _ratio), 0)

f_clamp(_x, _mn, _mx) => math.max(_mn, math.min(_x, _mx))

f_stars(_s) =>
    _s >= 5 ? "★★★★★" : _s >= 4 ? "★★★★" : _s >= 3 ? "★★★" : _s >= 2 ? "★★" : "★"

// ═══════════════════════════════════════════════════════════════════════════
// FAIR VALUE  — WMA + Hull MA blend (lower lag, smoother curve)
// ═══════════════════════════════════════════════════════════════════════════
wmaFair  = ta.wma(src, fairLen)
hmaFair  = ta.hma(src, fairLen)
fairRaw  = wmaFair * 0.60 + hmaFair * 0.40
fair     = ta.ema(fairRaw, math.max(1, int(smoothLen / 2)))

// ═══════════════════════════════════════════════════════════════════════════
// BAND WIDTH  — Percentile-based range + ATR (robust against spikes)
// ═══════════════════════════════════════════════════════════════════════════
atrValue  = ta.atr(14)
atrSma    = ta.sma(atrValue, 50)
pctRange  = ta.percentile_nearest_rank(high - low, zLen, 85)
baseWidth = pctRange * 0.70 + atrValue * 0.30
width     = ta.ema(baseWidth, smoothLen)
widthSma  = ta.sma(width, 50)

upperBand      = fair + width * upperMult
lowerBand      = fair - width * lowerMult
outerUpperBand = fair + width * upperMult * outerUpperMult
outerLowerBand = fair - width * lowerMult * outerLowerMult

// ═══════════════════════════════════════════════════════════════════════════
// 1. BAND COMPRESSION DETECTION
// ═══════════════════════════════════════════════════════════════════════════
comprRatio = width / math.max(widthSma, syminfo.mintick)
isCompr    = comprRatio < compressionThresh
isExpanded = comprRatio > 1.5
comprJustStarted = isCompr and not isCompr[1]

// ═══════════════════════════════════════════════════════════════════════════
// 2. BAND TOUCH MEMORY
// ═══════════════════════════════════════════════════════════════════════════
var int upperTouchCount = 0
var int lowerTouchCount = 0

upperTouch = high >= upperBand and close < upperBand
lowerTouch = low  <= lowerBand and close > lowerBand

if upperTouch
    upperTouchCount += 1
if lowerTouch
    lowerTouchCount += 1
if close > upperBand
    upperTouchCount := 0
if close < lowerBand
    lowerTouchCount := 0

upperMemory = f_clamp(upperTouchCount / 6.0, 0.0, 1.0)
lowerMemory = f_clamp(lowerTouchCount / 6.0, 0.0, 1.0)

// Band color: brighter with more touches; gold on compression
upperBandCol = isCompr ? color.new(goldCol, 15) : color.new(bearMain, int(70 - upperMemory * 55))
lowerBandCol = isCompr ? color.new(goldCol, 15) : color.new(bullMain, int(70 - lowerMemory * 55))

// ═══════════════════════════════════════════════════════════════════════════
// 3. REVERSAL EXHAUSTION
// ═══════════════════════════════════════════════════════════════════════════
var int upperRejCount = 0
var int lowerRejCount = 0

upperRej = high[1] >= upperBand[1] and close[1] < upperBand[1]
lowerRej = low[1]  <= lowerBand[1] and close[1] > lowerBand[1]

if upperRej
    upperRejCount += 1
if lowerRej
    lowerRejCount += 1
if close > upperBand
    upperRejCount := 0
if close < lowerBand
    lowerRejCount := 0

isUpperExh = upperRejCount >= exhaustionCount
isLowerExh = lowerRejCount >= exhaustionCount

// ═══════════════════════════════════════════════════════════════════════════
// FAIR VALUE LINE COLOR (slope-based)
// ═══════════════════════════════════════════════════════════════════════════
fairSlopeRaw    = nz(fair - fair[1], 0.0)
fairSlopeAvg    = ta.ema(math.abs(fairSlopeRaw), lineSlopeLen)
fairSlopeNorm   = fairSlopeAvg != 0.0 ? fairSlopeRaw / fairSlopeAvg : 0.0
fairSlopeSmooth = ta.ema(fairSlopeNorm, lineSlopeLen)
lineRatio       = f_clamp(0.5 + fairSlopeSmooth / (2.0 * lineSlopeSensitivity), 0.0, 1.0)
fairLineColor   = f_colorGradient(lineRatio, bearMain, bullMain)

// ═══════════════════════════════════════════════════════════════════════════
// REVERSAL CANDLE FILTER  — pin bar or engulfing at band touch
// ═══════════════════════════════════════════════════════════════════════════
_range      = high - low
_body       = math.abs(close - open)
_upperWick  = high - math.max(close, open)
_lowerWick  = math.min(close, open) - low

// Pin bar: wick > 2× body AND wick > 50% of full range
isBullPin   = _lowerWick > _body * 2.0 and _lowerWick > _range * 0.50
isBearPin   = _upperWick > _body * 2.0 and _upperWick > _range * 0.50

// Engulfing: opposite-direction candle that fully engulfs previous body
isBullEngulf = close > open and close[1] < open[1] and close > open[1] and open <= close[1]
isBearEngulf = close < open and close[1] > open[1] and close < open[1] and open >= close[1]

// Check both current bar and the prior touching bar
bullPattern = isBullPin or isBullEngulf or isBullPin[1] or isBullEngulf[1]
bearPattern = isBearPin or isBearEngulf or isBearPin[1] or isBearEngulf[1]

// ═══════════════════════════════════════════════════════════════════════════
// SIGNALS
// ═══════════════════════════════════════════════════════════════════════════
rawBuy  = close[1] <= lowerBand[1] and close > lowerBand
rawSell = close[1] >= upperBand[1] and close < upperBand

var int    lastSignalBar = na
var string lastSignal    = ""

barsOk    = na(lastSignalBar) or bar_index - lastSignalBar >= cooldownBars
patternOkBuy  = not usePatternFilter or bullPattern
patternOkSell = not usePatternFilter or bearPattern
finalBuy  = enableSignals and rawBuy  and barsOk and patternOkBuy
finalSell = enableSignals and rawSell and barsOk and patternOkSell

if finalBuy
    lastSignalBar := bar_index
    lastSignal    := "bull"
if finalSell
    lastSignalBar := bar_index
    lastSignal    := "bear"

// ═══════════════════════════════════════════════════════════════════════════
// 4. REVERSAL STRENGTH SCORE (1–5 ★)
// ═══════════════════════════════════════════════════════════════════════════
f_signalScore(_isBuy) =>
    _pen    = _isBuy ? math.max(0.0, lowerBand[1] - close[1]) : math.max(0.0, close[1] - upperBand[1])
    _pNorm  = _pen / math.max(width[1], syminfo.mintick)
    _atrR   = atrValue / math.max(atrSma,   syminfo.mintick)
    _wR     = width    / math.max(widthSma,  syminfo.mintick)
    int _s  = 1
    if _pNorm > 0.08
        _s += 1
    if _pNorm > 0.25
        _s += 1
    if _atrR > 1.1
        _s += 1
    if _wR > 1.1
        _s += 1
    math.min(5, _s)

buyScoreRaw  = f_signalScore(true)
sellScoreRaw = f_signalScore(false)
buyScore     = finalBuy  ? buyScoreRaw  : 0
sellScore    = finalSell ? sellScoreRaw : 0

// ═══════════════════════════════════════════════════════════════════════════
// CANDLE COLORING
// ═══════════════════════════════════════════════════════════════════════════
bandRange    = upperBand - lowerBand
ratioRaw     = bandRange != 0.0 ? (src - lowerBand) / bandRange : 0.5
ratioClamped = f_clamp(nz(ratioRaw, 0.5), 0.0, 1.0)

// Direction-based coloring: up candle = sky blue, down candle = orange
// Intensity scales with zone position — brightest when price in extreme zone
isUpCandle    = close >= open
bullIntensity = 1.0 - ratioClamped              // 1.0 at lower band → brightest blue
bearIntensity = ratioClamped                     // 1.0 at upper band → brightest orange
bullAlpha     = isUpCandle   ? int(f_clamp(60.0 - bullIntensity * 60.0, 0.0, 60.0)) : 75
bearAlpha     = not isUpCandle ? int(f_clamp(60.0 - bearIntensity * 60.0, 0.0, 60.0)) : 75
reversalCandleCol = isUpCandle ? color.new(#38BDF8, bullAlpha) : color.new(#F97316, bearAlpha)
latestSignalCol   = lastSignal == "bull" ? color.new(#38BDF8, 0) : lastSignal == "bear" ? color.new(#F97316, 0) : reversalCandleCol
candleCol         = candleMode == "Reversal Heat" ? reversalCandleCol : latestSignalCol

// ═══════════════════════════════════════════════════════════════════════════
// 5. FAIR VALUE PULL LINE (shows mean-reversion tension when price is stretched)
// Active only when price is more than 1.1× band-width away from fair value
// ═══════════════════════════════════════════════════════════════════════════
gravDist     = math.abs(close - fair)
gravDistATR  = gravDist / math.max(atrValue, syminfo.mintick)
isGravActive = showGravity and gravDist > width * 1.1
// Line sits at 55% between fair value and price — a visual "rubber band" tension
gravLine     = isGravActive ? fair + (close - fair) * 0.55 : na
// Color intensity scales with stretch: more stretched = more visible
gravAlpha    = isGravActive ? int(f_clamp(90.0 - (gravDistATR - 1.0) * 20.0, 55.0, 88.0)) : 100

// ═══════════════════════════════════════════════════════════════════════════
// PLOTS
// ═══════════════════════════════════════════════════════════════════════════
barcolor(candleCol, title="NRB Candle Color")

pUpper = plot(upperBand, "Upper Band", color=upperBandCol, linewidth=2)
pLower = plot(lowerBand, "Lower Band", color=lowerBandCol, linewidth=2)
pFair  = plot(fair,      "Fair Value", color=fairLineColor, linewidth=1)

// FV Pull line removed — use FV Stretch value in panel instead

pOuterUpper = plot(showOuterBands ? outerUpperBand : na, "Outer Upper", color=color.new(borderCol, 80), linewidth=1)
pOuterLower = plot(showOuterBands ? outerLowerBand : na, "Outer Lower", color=color.new(borderCol, 80), linewidth=1)

// Band fills — gold on squeeze, gray on exhaustion, normal otherwise
// High transparency (93%) so candles stay visible through the fills
upperFillCol = isCompr ? color.new(goldCol, 85) : isUpperExh ? color.new(color.gray, 93) : color.new(bearMain, 93)
lowerFillCol = isCompr ? color.new(goldCol, 85) : isLowerExh ? color.new(color.gray, 93) : color.new(bullMain, 93)

fill(pUpper, pFair, upperBand, fair, upperFillCol, emptyCol)
fill(pFair, pLower, fair, lowerBand, emptyCol, lowerFillCol)
fill(pOuterUpper, pUpper, outerUpperBand, upperBand,
     showOuterBands ? color.new(bearMain, 70)  : na,
     showOuterBands ? color.new(bearMain, 100) : na)
fill(pLower, pOuterLower, lowerBand, outerLowerBand,
     showOuterBands ? color.new(bullMain, 100) : na,
     showOuterBands ? color.new(bullMain, 70)  : na)

// ── Signal labels (label.new supports series string for stars) ─────────────
if finalBuy
    label.new(bar_index, low,
         text  = showStrength ? f_stars(buyScoreRaw) : "▲",
         style = label.style_label_up,
         color = bullMain,
         textcolor = color.new(color.black, 0),
         size  = size.small)

if finalSell
    label.new(bar_index, high,
         text  = showStrength ? f_stars(sellScoreRaw) : "▼",
         style = label.style_label_down,
         color = bearMain,
         textcolor = labelTextCol,
         size  = size.small)

// ── Compression alert shape ────────────────────────────────────────────────
plotshape(comprJustStarted, title="Squeeze Alert",
     location=location.abovebar, style=shape.labeldown,
     color=color.new(goldCol, 10), textcolor=color.black,
     text="SQUEEZE", size=size.small)

// Exhaustion state is shown via gray fill + panel "EXHAUSTED ✕" label
// No floating X markers — they add noise without clarity

// ═══════════════════════════════════════════════════════════════════════════
// INFO PANEL
// ═══════════════════════════════════════════════════════════════════════════
var table panel = na

float  bandPos    = f_clamp(bandRange > 0 ? (close - lowerBand) / bandRange * 100.0 : 50.0, 0.0, 100.0)
string regimeStr  = isCompr ? "SQUEEZE" : isExpanded ? "EXPANDING" : close > fair ? "BULLISH" : "BEARISH"
color  regimeCol  = isCompr ? goldCol : isExpanded ? color.new(color.white, 20) : close > fair ? bullMain : bearMain
int    barsAgo    = na(lastSignalBar) ? 0 : bar_index - lastSignalBar
string lastSigStr = na(lastSignalBar) ? "—" : (lastSignal == "bull" ? "▲ BUY  " : "▼ SELL  ") + str.tostring(barsAgo) + "b ago"
color  lastSigCol = lastSignal == "bull" ? bullMain : lastSignal == "bear" ? bearMain : color.new(color.white, 55)
float  fvDist     = gravDistATR   // already computed above
color  fvDistCol  = fvDist > 2.0 ? bearMain : fvDist > 1.0 ? goldCol : bullMain
color  posCol     = bandPos > 70.0 ? bearMain : bandPos < 30.0 ? bullMain : color.new(color.white, 30)
string upperStat  = isUpperExh ? "EXHAUSTED ✕" : str.tostring(upperTouchCount) + " touch" + (upperTouchCount == 1 ? "" : "es")
string lowerStat  = isLowerExh ? "EXHAUSTED ✕" : str.tostring(lowerTouchCount) + " touch" + (lowerTouchCount == 1 ? "" : "es")
color  upperStatC = isUpperExh ? color.new(color.gray, 20) : color.new(bearMain, 20)
color  lowerStatC = isLowerExh ? color.new(color.gray, 20) : color.new(bullMain, 20)

if showPanel and barstate.islast
    if na(panel)
        panel := table.new(position.bottom_right, 8, 14,
             bgcolor=color.new(color.rgb(6, 6, 14), 5),
             frame_color=color.new(color.white, 78), frame_width=1,
             border_color=color(na), border_width=0)

    color bgD   = color.new(color.rgb(6,  6,  14), 6)
    color bgM   = color.new(color.rgb(14, 14, 28), 10)
    color bgM2  = color.new(color.rgb(10, 10, 22), 10)
    color bgSep = color.new(color.white, 88)
    color bgPad = color.new(color.rgb(6,  6,  14), 18)

    for _r = 0 to 13
        for _c = 0 to 7
            table.cell(panel, _c, _r, "", bgcolor=bgPad, text_size=size.tiny)
    for _c = 0 to 7
        table.cell(panel, _c, 0,  " ", bgcolor=bgPad, text_size=size.small)
        table.cell(panel, _c, 13, " ", bgcolor=bgPad, text_size=size.small)

    // Row 1 — header
    table.merge_cells(panel, 1, 1, 5, 1)
    table.cell(panel, 1, 1, "  NOVA REVERSAL", text_color=color.white, bgcolor=bgD, text_size=size.normal, text_halign=text.align_left)
    table.merge_cells(panel, 6, 1, 6, 1)
    table.cell(panel, 6, 1, "LunqFX  ", text_color=color.new(color.white, 55), bgcolor=bgD, text_size=size.small, text_halign=text.align_right)

    // Row 2 — separator
    table.merge_cells(panel, 1, 2, 6, 2)
    table.cell(panel, 1, 2, "", bgcolor=bgSep, text_size=size.tiny)

    // Row 3 — Regime (big)
    table.merge_cells(panel, 1, 3, 6, 3)
    table.cell(panel, 1, 3, "   " + regimeStr + "   ", text_color=regimeCol,
         bgcolor=color.new(regimeCol, 90), text_size=size.large, text_halign=text.align_center)

    // Row 4 — separator
    table.merge_cells(panel, 1, 4, 6, 4)
    table.cell(panel, 1, 4, "", bgcolor=bgSep, text_size=size.tiny)

    // Row 5 — Zone Position (0%=bottom band, 100%=top band)
    table.merge_cells(panel, 1, 5, 3, 5)
    table.cell(panel, 1, 5, "  Zone Position", text_color=color.new(color.white, 40), bgcolor=bgM, text_size=size.normal, text_halign=text.align_left)
    table.merge_cells(panel, 4, 5, 6, 5)
    table.cell(panel, 4, 5, str.tostring(bandPos, "0.0") + "%  ", text_color=posCol, bgcolor=bgM, text_size=size.normal, text_halign=text.align_right)

    // Row 6 — FV Stretch (how far price is from fair value in ATR units)
    table.merge_cells(panel, 1, 6, 3, 6)
    table.cell(panel, 1, 6, "  FV Stretch", text_color=color.new(color.white, 40), bgcolor=bgM2, text_size=size.normal, text_halign=text.align_left)
    table.merge_cells(panel, 4, 6, 6, 6)
    table.cell(panel, 4, 6, str.tostring(fvDist, "0.00") + "× ATR  ", text_color=fvDistCol, bgcolor=bgM2, text_size=size.normal, text_halign=text.align_right)

    // Row 7 — Last Signal
    table.merge_cells(panel, 1, 7, 3, 7)
    table.cell(panel, 1, 7, "  Last Signal", text_color=color.new(color.white, 40), bgcolor=bgM, text_size=size.normal, text_halign=text.align_left)
    table.merge_cells(panel, 4, 7, 6, 7)
    table.cell(panel, 4, 7, lastSigStr + "  ", text_color=lastSigCol, bgcolor=bgM, text_size=size.normal, text_halign=text.align_right)

    // Row 8 — separator
    table.merge_cells(panel, 1, 8, 6, 8)
    table.cell(panel, 1, 8, "", bgcolor=bgSep, text_size=size.tiny)

    // Row 9 — section label
    table.merge_cells(panel, 1, 9, 6, 9)
    table.cell(panel, 1, 9, "   BAND MEMORY", text_color=color.new(color.white, 55), bgcolor=bgD, text_size=size.small, text_halign=text.align_left)

    // Row 10 — Upper band
    table.merge_cells(panel, 1, 10, 3, 10)
    table.cell(panel, 1, 10, "  ▼ Upper", text_color=color.new(bearMain, 10), bgcolor=bgM, text_size=size.normal, text_halign=text.align_left)
    table.merge_cells(panel, 4, 10, 6, 10)
    table.cell(panel, 4, 10, upperStat + "  ", text_color=upperStatC, bgcolor=bgM, text_size=size.normal, text_halign=text.align_right)

    // Row 11 — Lower band
    table.merge_cells(panel, 1, 11, 3, 11)
    table.cell(panel, 1, 11, "  ▲ Lower", text_color=color.new(bullMain, 10), bgcolor=bgM2, text_size=size.normal, text_halign=text.align_left)
    table.merge_cells(panel, 4, 11, 6, 11)
    table.cell(panel, 4, 11, lowerStat + "  ", text_color=lowerStatC, bgcolor=bgM2, text_size=size.normal, text_halign=text.align_right)

    // Row 12 — separator
    table.merge_cells(panel, 1, 12, 6, 12)
    table.cell(panel, 1, 12, "", bgcolor=bgSep, text_size=size.tiny)

else if not showPanel and not na(panel)
    table.delete(panel)
    panel := na

// ═══════════════════════════════════════════════════════════════════════════
// ALERTS
// ═══════════════════════════════════════════════════════════════════════════
alertcondition(finalBuy,                           title="Buy Signal",        message="LRB: Buy signal — price crossed above lower band")
alertcondition(finalSell,                          title="Sell Signal",       message="LRB: Sell signal — price crossed below upper band")
alertcondition(comprJustStarted,                   title="Band Squeeze",      message="LRB: Band compression detected — breakout incoming")
alertcondition(isUpperExh and not isUpperExh[1],   title="Upper Exhausted",   message="LRB: Upper band exhausted — potential breakout up")
alertcondition(isLowerExh and not isLowerExh[1],   title="Lower Exhausted",   message="LRB: Lower band exhausted — potential breakout down")
